#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# pgman is a free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License, version 2 as
# published by the Free Software Foundation.
#
# pgman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
# License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Pwman3; if not, write to the Free Software Foundation,
# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# Copyright (C) 2008, 2009
#               Changsheng Jiang <jiangzuoyan@gmail.com>

import pyme, pyme.core
from pyme.core import Context as GPGContext, Data as GPGData
import os
import sys
import getpass
import random
import uuid
import shlex
import readline
import getopt
import re
import time

pguser=None
pgdbfile=None
pgpass=None
pgnodes={}
pgautoforget=60*10

pyme.core.check_version(None)
gpg_context = GPGContext()
gpg_context.set_armor(1)
gpg_receiver = None

def passphrase_cb(hint, desc, prev_bad):
    global pgpass, pguser
    if not pguser:
        pguser = hint[hint.rfind('<'):][1:-1]
    if prev_bad:
        pgpass = None
    if pgpass != None:
        return pgpass
    pw = getpass.getpass('%sPassphrase for %s:' % (prev_bad and 'Bad passphase, again\n' or '', hint))
    pgpass = pw
    return pw

def pg_init():
    global gpg_receiver, pgnodes, pgdbfile, pguser
    gpg_context.set_passphrase_cb(passphrase_cb)
    if not pgdbfile:
        pgdbfile = os.path.join(os.getenv('HOME'), '.pgman.gpg')
    pgnodes = read_nodes()
    if not pguser:
        pguser = get_input('please enter the email address corresponding to your private key: ')
    receivers = [k for k in gpg_context.op_keylist_all(pguser, 0)]
    if len(receivers) != 1:
        print 'gpg key %s %s' % (pguser, len(receivers) > 1 and 'has more than one' or 'not exists')
        sys.exit(1)
    gpg_receiver = receivers[0]

def decrypt_from_file(fn):
    fp = open(fn, 'rb')
    d = fp.read()
    fp.close()
    ddata = GPGData(d)
    pdata = GPGData()
    ddata.seek(0,0)
    gpg_context.op_decrypt(ddata, pdata)
    pdata.seek(0,0)
    return pdata.read()

def encrypt_to_file(fn, plain):
    cipher = GPGData()
    pdata = GPGData(plain)
    gpg_context.op_encrypt([gpg_receiver], 1, pdata, cipher)
    cipher.seek(0,0)
    ddata = cipher.read()
    fp = open(fn, 'wb')
    fp.write(ddata)
    fp.close()

def read_nodes():
    content = ''
    try:
        if (os.path.isfile(pgdbfile)):
            content = decrypt_from_file(pgdbfile)
    except:
        print 'got exception when read notes from %s:%s' % (pgdbfile, sys.exc_info()[1])
        sys.exit(1)
    lines = content.split('\n')
    nodes = {}
    node = None
    for line in lines:
        if line.startswith('#'):
            continue
        if line == '' and node:
            nodes[node['ID']] = node
            node = None
            continue
        if line.find(':') < 0:
            continue
        k, v = line.split(':', 1)
        if not node:
            node = dict()
        node[k] = v
    if node and node.get('ID'):
        nodes[node['ID']] = node
        node = None
    return nodes

def check_load_nodes():
    global pgnodes
    if not pgnodes:
        pgnodes = read_nodes()

def write_nodes():
    global pgnodes,pgdbfile
    content = ''
    for node in pgnodes.itervalues():
        content += '\n'
        for k in node:
            content += '%s:%s\n' % (k, node[k].replace('\n', ' '))
        content += '\n'
    encrypt_to_file(pgdbfile, content)

def new_id():
    return uuid.uuid4().hex


'''
Filter Semantic:
Filter => FilterAFactor |
          FilterAFactor or Filter
FilterAFactor => FilterFactor
               | FilterFactor and FilterAFactor
               | FilterFactor FilterAFactor
FilterFactor => ( Filter )
              | -Feilds value
              | not FilterFactor
'''
def node_is_match_filter(node, filter, start=0):
    [lr, ls] = node_is_match_afactor(node, filter, start)
    if ls < len(filter):
        if filter[ls] == 'or':
            [rr, rs] = node_is_match_filter(node, filter, ls + 1)
            return [rr or lr, rs]
    return [lr, ls]

def node_is_match_afactor(node, filter, start):
    [lr, ls] = node_is_match_factor(node, filter, start)
    if ls < len(filter):
        if filter[ls] == 'and':
            [rr, rs] = node_is_match_afactor(node, filter, ls + 1)
            return [lr and rr, rs]
        elif not filter[ls] in ['or', ')']:
            [rr, rs] = node_is_match_afactor(node, filter, ls)
            return [lr and rr, rs]
    return [lr, ls]

def node_is_match_factor(node, filter, start):
    if start >= len(filter):
        raise Exception('invalid filter, unexpected end')
    if filter[start] == '(':
        [r, s] = node_is_match_filter(node, filter, start + 1)
        if s >=len(filter) or filter[s] != ')':
            raise Exception('invalid filter, expected a )')
        return [r, s + 1]
    if filter[start] == 'not':
        [r, s] = node_is_match_factor(node, filter, start + 1)
        return [not r, s]
    elif filter[start].startswith('-'):
        if len(filter) >= start + 2:
            field = filter[start][1:]
            value = filter[start+1]
            return [node_is_match_field(node, field, value), start+2]
        else:
            raise Exception('invalid filter, expected a field value')
    elif set(filter[start].lower()).issubset(set('0123456789abcdef')):
        id = filter[start]
        return [node['ID'].startswith(id), start+1]
    else:
        raise Exception('invalid filter, unexpected "%s"'%filter[start])

def node_is_match_field(node, field, value):
    maps = {'u': 'username', 'user': 'username', 't': 'tags', 'tag': 'tags',
            'n': 'notes', 'l': 'url', 'p': 'password'}
    mfield = field.lower()
    if maps.get(mfield):
        mfield = maps[mfield]
    nfield = mfield.capitalize()
    if mfield == 'id':
        nfield = 'ID'
    nvalue = node.get(nfield, '')
    exact = not field.islower()
    if mfield == 'tags':
        ntags = set(nvalue.split(','))
        vtags = set(value.split(','))
        if exact:
            return vtags == ntags
        else:
            return vtags.issubset(ntags)
    if exact:
        return nvalue == value
    else:
        return nvalue.find(value) >= 0

def filter_nodes(nodes, filter):
    if len(filter) == 0:
        return nodes
    return [n for n in nodes if node_is_match_filter(n, filter)[0]]

def list_command(argv):
    '''
    Usage: list [print_options] [filter]
    print the filtered items
    print options:
     -o (default)one line for every item
     -v expand
     -V expand, password show as plain
     -x password to clipboard
    filters syntax:
     keywords: not and or ( )
     alias: u,user: username t,tag: tags  l:url n:notes
     -U exactly match username
     -u part match username
    Example: list ( -T net or not -tags bbs ) or not -user username
    Please take care spaces around the '(' and ')'
    '''
    check_load_nodes()
    if len(argv) >= 2 and re.match('-[ovVx]+$', argv[1]):
        way = argv[1]
        filter = argv[2:]
    else:
        way = 'o'
        filter = argv[1:]
    try:
        nodes = filter_nodes(pgnodes.itervalues(), filter)
        for node in nodes:
            print_node(node, way)
    except Exception, inst:
        print "error,", inst

def print_node(node, way):
    '''
    print the node
    '''
    check_load_nodes()
    if 'v' in way or 'V' in way:
        print node['ID'], 'Tags:', node['Tags']
        print 'User:', node['Username'], ':', ('V' in way and node['Password'] or '********')
        print 'Url:', node['Url']
        print 'Notes:', node['Notes']
        print ''
    elif 'o' in way:
        str = '%s %s\t%s' % (node['ID'],node['Username'] + '@' + node['Url'], node['Tags'])
        print str
    if 'x' in way:
        try:
            xsel = '/usr/bin/xsel -b -i'
            xclip = '/usr/bin/xclip -i -selection clipboard'
            xcom = None
            if os.path.isfile(xsel.split(' ')[0]):
               xcom = xsel
            elif os.path.isfile(xclip.split(' ')[0]):
                xcom = xclip
            if xcom:
                clip = os.popen(xcom, 'w')
                clip.write(node['Password'])
                clip.close()
            else:
                print 'no xclip or xsel found, no clipboard support'
        except:
            print 'print to clipboard failed by %s' % xcom

def gen_password(len):
    '''
    XXX: more specific requirement such as range, existance
    '''
    chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    import random
    return "".join([random.sample(chars, 1)[0] for i in range(len)])

def get_input(desc, default=""):
    def defaulter():
        readline.insert_text(default)
    readline.set_startup_hook(defaulter)
    try:
        x = raw_input(desc)
    except:
        raise
    finally:
        readline.set_startup_hook()
    return x

def interactive_command(inkeys, actioner):
    '''
    perform a command, get input according the inkeys,  than call actioner(input)
    inkeys: dict-key => {desc: ..., type:...., default:....,}
    '''
    input = {}
    try:
        for k,v in inkeys.items():
            desc = v.get('desc',  k) + ':'
            if v.get('type', 'string') == 'string':
                input[k] = get_input(desc, v.get('default', ''))
                continue
            if v['type'] in ('passphrase', 'password'):
                input[k] = input_password_twice(desc, empty_leave=True) or v.get('default','')
                continue
            if v['type'] in ('callback', 'function'):
                input[k] = v['func']()
                continue
        actioner(input)
    except:
        print '\nfailed to complete command "%s"' % inkeys['info']['info']
        raise

def input_password_twice(desc, empty_leave=False, non_empty=False):
    while 1:
        pw = getpass.getpass(desc)
        if len(pw) == 0 and empty_leave:
            return pw
        if non_empty:
            print 'non empty password allowed, again:'
            continue
        print 'please input the same password again:'
        npw = getpass.getpass(desc)
        if npw != pw:
            print 'error, not the same passwords entered, again:'
            continue
        return pw

def ask_password_or_auto():
    upw = input_password_twice('Password:', empty_leave=True)
    if len(upw):
        return upw
    pwlen = 8
    dpwlen = pwlen
    upwlen = raw_input('password len<%s>:' % dpwlen)
    try:
        if len(upwlen):
            pwlen = int(upwlen)
    except:
        print 'error length, using %s' % dpwlen
    if pwlen >= 3000 or pwlen < 0:
        print 'error length range, using %s' % dpwlen
        pwlen = dpwlen
    return gen_password(pwlen)

def normalize_node(node):
    node['Tags'] = ','.join([tag.strip() for tag in node['Tags'].split(',')])
    node['Url'] = node['Url'].strip()
    return node

def new_command(argv):
    '''
    add a new username/password item
    '''
    check_load_nodes()
    new_list = {
        'info': {'name' : 'new', 'type': 'info', 'info': 'new'},
        'Username': {},
        'Password': {'type': 'callback', 'func' : ask_password_or_auto},
        'Url': {},
        'Notes': {},
        'Tags': {}
        }
    def new_actioner(input):
        node = input.copy()
        id = new_id()
        node['ID'] = id
        pgnodes[id] = normalize_node(node)
        write_nodes()
        print 'Added new item id=%s' % id
    try:
        interactive_command(new_list, new_actioner)
    except:
        print 'the new item not added'

def forget_command(argv):
    '''
    forget the password entered
    '''
    global pgpass,pgnodes
    pgpass = None
    pgnodes = None

def help_command(argv):
    '''
    print help information of given commands
    '''
    coms = argv[1:]
    if len(coms) == 0:
        coms = commands.keys()
        print 'available commands: %s' % '\t'.join(coms)
        return
    for com in coms:
        do_help(com)

def do_help(com):
    global commands
    if com in commands:
        print '%s:%s' % (com, commands.get(com).__doc__)
    else:
        print 'invalid command? %s\n' % com

def edit_command(argv):
    '''
    Usage: edit ...
    Edit items
    '''
    check_load_nodes()
    try:
        nodes = filter_nodes(pgnodes.itervalues(), argv[1:])
        for node in nodes:
            do_edit(node)
    except Exception, inst:
        print 'error, %s, edit command not finished' % inst

def do_edit(node):
    id = node['ID']
    edit_list = {
        'info': {'type':'info', 'name': 'edit', 'info':'edit %s' % id},
        'Username': {'default': node['Username']},
        'Url': {'default': node['Url']},
        'Notes': {'default': node['Notes']},
        'Tags': {'default': node['Tags']},
        'Password': {'default': node['Password'], 'type': 'password'}
        }
    print 'Edit node:'
    print_node(node, '-v')
    def edit_actioner(input):
        node.update(input)
        pgnodes[id] = normalize_node(node)
        write_nodes()
        print 'Edited node %s' % id
    interactive_command(edit_list, edit_actioner)

def ask_yes_or_no(desc, yes='yes', no='no'):
    while True:
        ans = raw_input(desc + '(%s/%s)' % (yes, no))
        if ans == yes:
            return ans
        elif ans == no:
            return no
        print 'You must print %s or %s exactly' % (yes, no)

def rm_command(argv):
    '''
    Usage: rm ...
    remove items
    '''
    check_load_nodes()
    try:
        nodes = filter_nodes(pgnodes.itervalues(), argv[1:])
        for node in nodes:
            ans = ask_yes_or_no('Are you sure to delete item %s:%s@%s' % (node['ID'], node['Username'], node['Url']))
            if ans == 'yes':
                pgnodes.pop(node['ID'])
                write_nodes()
    except Exception, inst:
        print 'error %s, when rm, breaked?' % inst

def genpw_command(argv):
    '''
    Usage: genpw len
    generate a password
    '''
    l = len(argv) == 2 and argv[1] or "8"
    l = l.isdigit() and int(l) or 8
    print gen_password(l)

def get_key_by_start(d, k, unique=False):
    ks = d.keys()
    a = []
    for rk in ks:
        if rk.startswith(k):
            a.append(rk)
    if unique:
        if len(a) == 1:
            return a[0]
        return None
    return a

commands={
    'help': help_command,
    'list': list_command,
    'new': new_command,
    'forget': forget_command,
    'rm': rm_command,
    'edit': edit_command,
    'genpw': genpw_command
}

def print_help():
    print 'pgman: passwords guard manager'
    print ' -d passwords-saved-file.gpg'
    print ' -u gpg receiever mail address'
    print ' -t forget timeout, in seconds'
    print ' -h print help message'

def main(argv = []):
    global pgdbfile, pgnodes, pguser, pgautoforget
    try:
        opts, argv = getopt.getopt(argv[1:], 'd:u:t:h')
    except:
        print 'error:', sys.exc_info()[1]
        print_help()
        return
    for k,v in opts:
        if k == '-d':
            pgdbfile = v
        if k == '-u':
            pguser = v
        if k == '-t':
            try:
                pgautoforget = int(v)
            except:
                print 'option -t expected a integer value in seconds'
                return
        if k == '-h':
            print_help()
            return
    pg_init()
    otime = time.time()
    while 1:
        try:
            line = raw_input('pgman>')
        except:
            return
        argv = shlex.split(line)
        if not len(argv):
            continue
        ntime = time.time()
        if ntime - otime >= pgautoforget:
            forget_command([])
        otime = ntime
        k = get_key_by_start(commands, argv[0], True)
        if k:
            commands.get(k)(argv)
        else:
            print '"%s" not a command, try "help"' % argv[0]

if __name__ == '__main__':
    main(sys.argv)
